Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Compare DocumentNode used in refetchQueries as strings #12236

Merged

Conversation

charpeni
Copy link
Contributor

@charpeni charpeni commented Dec 18, 2024

Fixes #12164.

We have the ability to pass a DocumentNode directly to refetchQueries, but unfortunately, it doesn't always work as expected. DocumentNode are compared by references as they are added as an object to the Map. See the issue mentioned above (#12164) for a detailed investigation.

To prevent active queries from being marked as unknown by Apollo Client, we can compare DocumentNodes by value rather than reference. To do so, we can leverage the internal print function (thanks @jerelmiller!).

Copy link

netlify bot commented Dec 18, 2024

👷 Deploy request for apollo-client-docs pending review.

Visit the deploys page to approve it

Name Link
🔨 Latest commit 03334ab

@svc-apollo-docs
Copy link

svc-apollo-docs commented Dec 18, 2024

✅ Docs Preview Ready

No new or changed pages found.

Copy link

pkg-pr-new bot commented Dec 18, 2024

npm i https://pkg.pr.new/@apollo/client@12236

commit: 03334ab

Copy link

changeset-bot bot commented Dec 18, 2024

🦋 Changeset detected

Latest commit: 03334ab

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@apollo/client Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@jerelmiller
Copy link
Member

Don't worry about the failing size report. I'll make sure that gets updated before this gets merged 🙂

typeof nameOrDoc === "string" ?
`Unknown query named "%s" requested in refetchQueries options.include array`
: `Unknown query %o requested in refetchQueries options.include array`,
`Unknown query %s requested in refetchQueries options.include array`,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't sure what to do here.

We don't have a way to differentiate query name from a DocumentNode anymore, so we can either always say Unknown query, check if nameOrDoc starts with query (<- the trailing space is important), or have the map holding an object instead of only a boolean: { type, included }.

Copy link
Member

@jerelmiller jerelmiller Dec 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its probably a good idea to add a fallback name here that identifies it as anonymous, otherwise this might get unreadable for large queries. Here is a quick test I did with this change if you pass an unrecognized document node:

Unknown query query fakeQuery {                                                           
  fake                                                                                                                                                                                  
} requested in refetchQueries options.include array

Even with this being a single field query, its already a bit unreadable.

That said, the old message wasn't much better 😂

Unknown query {                                                                           
  kind: 'Document',                                                                                                                                                                     
  definitions: [                                                                          
    {                                                                                                                                                                                   
      kind: 'OperationDefinition',                                                                                                                                                      
      operation: 'query',                                                                                                                                                               
      name: { kind: 'Name', value: 'fakeQuery' },                                                                                                                                       
      variableDefinitions: [ [length]: 0 ],                                               
      directives: [ [length]: 0 ],                                                                                                                                                      
      selectionSet: { kind: 'SelectionSet', selections: [ [Object], [length]: 1 ] }                                                                                                     
    },                                                                                    
    [length]: 1                                                                                                                                                                         
  ],                                                                                                                                                                                    
  loc: Location {                                                                                                                                                                       
    start: 0,                                                                                                                                                                           
    end: 66,                                                                                                                                                                            
    source: Source {                                                                                                                                                                    
      body: '\n          query fakeQuery {\n            fake\n          }\n        ',                                                                                                   
      name: 'GraphQL request',                                                            
      locationOffset: { line: 1, column: 1 },                                                                                                                                           
      [Symbol(Symbol.toStringTag)]: [Getter]                                                                                                                                            
    },                                                                                                                                                                                  
    [Symbol(Symbol.toStringTag)]: [Getter]                                                                                                                                              
  }                                                                                       
} requested in refetchQueries options.include array

Would you be willing to add an additional test case for an anonymous query document passed to refetchQueries that hits this condition? We probably should have had one all-along to prevent that monstrosity, but could be a good time to improve the error message while we're here.

I think using "anonymous" as the fallback name would be ok since we've used "anonymous" in other places that print out query strings. We've got a getOperationName utility function that can extract it from a document node that can help determine if it has a name.

Let me know what you think about that!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To confirm the expectation, are you suggesting we keep only the branch Unknown query named "%s" requested for both cases? In other words, we never display the document node; we only show the query name or extract it from the document node via getOperationName.

It gets noisy quickly. In most cases, an operation name would be more helpful than the entire document node.

If so, could it be problematic for an unknown anonymous query? E.g.:

query {
  fake
}

Would produce:

Unknown query anonymous requested in refetchQueries options.include array

How do we debug this if the query is anonymous and we don't show the content?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the confusion! Yes I'm suggesting we display this:

Unknown query "fakeQuery" requested in refetchQueries options.include array

instead of this:

Unknown query query fakeQuery {                                                           
  fake                                                                                                                                                                                  
} requested in refetchQueries options.include array

for the error message, at least for named queries. I was thinking we'd do the same for anonymous queries and show your 2nd suggestion there:

Unknown query anonymous requested in refetchQueries options.include array

but I suppose showing the full query here isn't the end of the world.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe you are correct.

Showing the full query only adds noise, and since warnings aren't shown by default, the URL leading to the error page is too long to be clickable. 😅

I've confirmed that the warning triggered by the invariant has a stack trace leading to the refetchQueries option. It should be enough to debug cases where we're tracking down an unknown query. In most cases, having the named query in a one-liner warning should be the optimal developer experience.

I just sent a commit refactoring the logic mentioned above and added missing tests for warnings, including the anonymous query.

Screenshot 2024-12-19 at 9 55 09 AM

@charpeni
Copy link
Contributor Author

Thank you!

I just confirmed that running this pull request (via https://pkg.pr.new/@apollo/client@12236) within our application fixed all issues related to refetchQueries. Only active queries are refetched, even if DocumentNode references aren't matching, inactive queries are still showing up as a warning. 🎉

It means we will be able to finalize the migration from the legacy { query, variables } or even query names as magic strings to fully embrace reusing DocumentNodes, super exciting!

@charpeni charpeni marked this pull request as ready for review December 18, 2024 20:47
Copy link
Member

@jerelmiller jerelmiller left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change looks great!

The only change I'm requesting here is to have a test case for passing an anonymous query document to refetchQueries so that we can at least have that error message written down somewhere.

Let me know what you think about having a fallback name so that we can avoid the hideous error message. Even if it goes unchanged, at least its an improvement from before since it prints the query string instead of the AST.

.changeset/gorgeous-sheep-knock.md Outdated Show resolved Hide resolved
.changeset/gorgeous-sheep-knock.md Outdated Show resolved Hide resolved
src/core/__tests__/QueryManager/index.ts Outdated Show resolved Hide resolved
);
expect(observable.getCurrentResult().data).toEqual(secondReqData);

await wait(10);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once we remove that check for legacyOneTimeQuery, we should be able to remove this as well since await expect(stream).not.toEmitAnything() will wait for 100ms on its own.

src/core/__tests__/QueryManager/index.ts Outdated Show resolved Hide resolved
typeof nameOrDoc === "string" ?
`Unknown query named "%s" requested in refetchQueries options.include array`
: `Unknown query %o requested in refetchQueries options.include array`,
`Unknown query %s requested in refetchQueries options.include array`,
Copy link
Member

@jerelmiller jerelmiller Dec 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its probably a good idea to add a fallback name here that identifies it as anonymous, otherwise this might get unreadable for large queries. Here is a quick test I did with this change if you pass an unrecognized document node:

Unknown query query fakeQuery {                                                           
  fake                                                                                                                                                                                  
} requested in refetchQueries options.include array

Even with this being a single field query, its already a bit unreadable.

That said, the old message wasn't much better 😂

Unknown query {                                                                           
  kind: 'Document',                                                                                                                                                                     
  definitions: [                                                                          
    {                                                                                                                                                                                   
      kind: 'OperationDefinition',                                                                                                                                                      
      operation: 'query',                                                                                                                                                               
      name: { kind: 'Name', value: 'fakeQuery' },                                                                                                                                       
      variableDefinitions: [ [length]: 0 ],                                               
      directives: [ [length]: 0 ],                                                                                                                                                      
      selectionSet: { kind: 'SelectionSet', selections: [ [Object], [length]: 1 ] }                                                                                                     
    },                                                                                    
    [length]: 1                                                                                                                                                                         
  ],                                                                                                                                                                                    
  loc: Location {                                                                                                                                                                       
    start: 0,                                                                                                                                                                           
    end: 66,                                                                                                                                                                            
    source: Source {                                                                                                                                                                    
      body: '\n          query fakeQuery {\n            fake\n          }\n        ',                                                                                                   
      name: 'GraphQL request',                                                            
      locationOffset: { line: 1, column: 1 },                                                                                                                                           
      [Symbol(Symbol.toStringTag)]: [Getter]                                                                                                                                            
    },                                                                                                                                                                                  
    [Symbol(Symbol.toStringTag)]: [Getter]                                                                                                                                              
  }                                                                                       
} requested in refetchQueries options.include array

Would you be willing to add an additional test case for an anonymous query document passed to refetchQueries that hits this condition? We probably should have had one all-along to prevent that monstrosity, but could be a good time to improve the error message while we're here.

I think using "anonymous" as the fallback name would be ok since we've used "anonymous" in other places that print out query strings. We've got a getOperationName utility function that can extract it from a document node that can help determine if it has a name.

Let me know what you think about that!

Comment on lines 976 to 978
const isQueryString =
nameOrQueryString.startsWith("query ") ||
nameOrQueryString.startsWith("{"); // Shorthand anonymous queries
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not fully happy with how it ended up here.

At this stage, nameOrQueryString is always a string, but we need to know whether it's a query name, or a printed document node. I'm not sure if we wanted to use AST utils here to parse the string and try to capture the operation node out of it, but it seems like it could be expensive, and they are built with invariants, too. I opted to check if it starts with either query or { (a shorthand for an anonymous query starts with a selection set).

Let me know what you think.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It actually might be easiest to just add another Map where the key is the stringified query and the value is the query name (or null in the case that its anonymous), that way we can skip the parsing step and just add it where we know its a DocumentNode on line 910.

const queryNames = new Map<string, string | null>();

// line 910
} else if (isDocumentNode(desc)) {
  const queryString = print(this.transform(desc));
  queryNames.set(queryString, getOperationName(desc));
  queryNamesAndQueryStrings.set(queryString, false);
}

Then getting the query name would be as simple as:

const queryName = queryNames.get(nameOrQueryString);

In fact, it might also be best just to put the case where you're passing query strings in there as well, then you wouldn't need the isQueryString check:

// line 909
if (typeof desc === "string") {
  queryNames.set(desc, desc);
  queryNamesAndQueryStrings.set(desc, false);

This way queryNames.get(...) will work in both cases. This at least skips the need to parse again.

Thoughts?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds like a good idea! I was on the fence between that or turning the actual map values into an object: { included, queryName }, but I think it's probably better to split those concerns for now.

Will do those changes, thank you!

@jerelmiller
Copy link
Member

jerelmiller commented Dec 19, 2024

by the way, ignore that failing test case with expect(onCompleted).toHaveBeenCalledTimes(1);. We have a flaky test that we keep forgetting to fix. That failure is not caused by this PR.

Copy link
Member

@jerelmiller jerelmiller left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💯 Thanks so much for fixing this! I'll get this in the next patch release 🙂

@jerelmiller jerelmiller merged commit 4334d30 into apollographql:main Dec 19, 2024
40 of 43 checks passed
@github-actions github-actions bot mentioned this pull request Dec 19, 2024
@charpeni charpeni deleted the 12164-refetch-queries-with-document-node branch December 19, 2024 16:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

DocumentNode passed to refetchQueries are compared by references
3 participants